@ceedcv-maya/shared-editor-react 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/package.json +87 -0
  4. package/src/components/ColorPicker.tsx +100 -0
  5. package/src/components/CommentHoverPopover.tsx +82 -0
  6. package/src/components/EditorContentHtml.tsx +29 -0
  7. package/src/components/EditorToolbar.tsx +225 -0
  8. package/src/components/EditorToolbarButton.tsx +32 -0
  9. package/src/components/EditorToolbarGroups.tsx +401 -0
  10. package/src/components/FindReplaceBar.tsx +253 -0
  11. package/src/components/MayaEditor.tsx +379 -0
  12. package/src/components/SourceInputDialog.tsx +120 -0
  13. package/src/extensions/AlertBlock.ts +59 -0
  14. package/src/extensions/CommentMark.ts +57 -0
  15. package/src/extensions/IframeBlock.ts +76 -0
  16. package/src/extensions/Indent.ts +133 -0
  17. package/src/hooks/useEditorContent.ts +47 -0
  18. package/src/i18n/en.json +54 -0
  19. package/src/i18n/es.json +54 -0
  20. package/src/index.ts +47 -0
  21. package/src/lib/CommentAnchor.ts +68 -0
  22. package/src/lib/docxToHtml.ts +58 -0
  23. package/src/lib/dompurifyConfig.test.ts +98 -0
  24. package/src/lib/dompurifyConfig.ts +123 -0
  25. package/src/lib/editorExtensions.ts +73 -0
  26. package/src/lib/htmlToMarkdown.ts +166 -0
  27. package/src/lib/htmlToTiptapDoc.test.ts +52 -0
  28. package/src/lib/htmlToTiptapDoc.ts +26 -0
  29. package/src/lib/markdownToHtml.ts +234 -0
  30. package/src/lib/normalizeTableHtml.ts +74 -0
  31. package/src/lib/splitHtmlIntoBlocks.test.ts +86 -0
  32. package/src/lib/splitHtmlIntoBlocks.ts +136 -0
  33. package/src/serializers/BlockNoteToTiptap.ts +223 -0
  34. package/src/styles/maya-editor.css +538 -0
  35. package/src/types.ts +56 -0
  36. package/tsconfig.json +20 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maya-AQSS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @ceedcv-maya/shared-editor-react
2
+
3
+ Unified TipTap editor for the Maya ecosystem.
4
+
5
+ ## Components
6
+
7
+ - **`<MayaEditor mode="lite" | "full" />`** — single editor with two visual modes. `lite` for short comments and alerts; `full` for templates and documents (BlockNote parity).
8
+ - **`<EditorContentHtml html />`** — read-only renderer with DOMPurify sanitisation (aligned with the server-side `TiptapHtmlRenderer`).
9
+ - **`<EditorToolbar />`** — toolbar builder, used internally by `MayaEditor` and exposed for custom integrations.
10
+
11
+ ## Extensions
12
+
13
+ - `IframeBlock` — sandboxed iframe block with optional domain allowlist.
14
+ - `AlertBlock` — variants info / warning / success / danger.
15
+ - `CommentMark` — anchored-comment mark (paired with `AnchoredCommentController` server-side).
16
+
17
+ ## Conversion
18
+
19
+ - `convertBlockNoteToTiptap(blocks)` — legacy → ProseMirror conversion. Mirror of the PHP `Maya\Editor\Renderers\BlockNoteToTiptap`.
20
+
21
+ ## Document import & block splitting
22
+
23
+ Helpers behind the "Import from Word → blocks" flow (`DocxBlockSplitter` in maya_dms):
24
+
25
+ - `docxToHtml(file)` / `docxToHtmlResult(file)` — convert a `.docx` `File` to sanitised, editor-ready HTML via mammoth (loaded dynamically, ~430KB). `docxToHtmlResult` also returns mammoth's `messages` (warnings for unrecognised styles / track changes that may not import cleanly).
26
+ - `splitHtmlIntoBlocks(html)` — split an HTML fragment into top-level `BlockChunk[]` (heading / paragraph / list / table / figure / blockquote / codeBlock / horizontalRule / other), preserving each element's `outerHTML` for a lossless round-trip. **List items are exploded one chunk per `<li>`** (re-wrapped in their `<ul>`/`<ol>`) so callers can assign each bullet to a different block. Empty whitespace/`<br>`-only paragraphs are flagged `isEmpty`.
27
+ - `htmlToTiptapDoc(html, extensions?)` — convert HTML to a TipTap JSON doc using a headless editor with the canonical Maya extensions, so the round-trip matches what the live editor persists. Empty input yields a doc with one empty paragraph (TipTap's normalised empty state).
28
+ - `buildMayaEditorExtensions(mode)` — the canonical TipTap extension list, shared by `MayaEditor` and `htmlToTiptapDoc` (single source of truth for the schema).
29
+
30
+ ```ts
31
+ const html = await docxToHtml(file);
32
+ const chunks = splitHtmlIntoBlocks(html).filter((c) => !c.isEmpty);
33
+ // …user groups chunks into blocks…
34
+ const doc = htmlToTiptapDoc(groupHtml); // → persist doc.content
35
+ ```
36
+
37
+ Known mammoth limitations: track changes and comments are dropped; non-standard Word styles may not map. `docxToHtmlResult().messages` surfaces these.
38
+
39
+ ## Anchored comments
40
+
41
+ - `getAnchorRange(editor, commentId)` / `setAnchorRange(editor, id, range)`
42
+ - `rebaseAnchors(anchors, tr)` — applies a ProseMirror `Transaction.mapping` to a list of anchors and flags collapsed ones as invalid.
43
+
44
+ ## Sanitisation
45
+
46
+ - `sanitizeEditorHtml(rawHtml)` — DOMPurify config covering the full set of tags emitted by `TiptapHtmlRenderer` (paragraph, headings, lists, tables, blockquote, code, images, iframes with `sandbox`, alerts).
47
+
48
+ ## Usage
49
+
50
+ ```tsx
51
+ import { MayaEditor } from '@ceedcv-maya/shared-editor-react';
52
+
53
+ <MayaEditor
54
+ mode="full"
55
+ initialContent={template.html}
56
+ editable={canEdit}
57
+ onChange={(html) => save(html)}
58
+ isDark={theme === 'dark'}
59
+ />
60
+ ```
61
+
62
+ Lite mode for short comments:
63
+
64
+ ```tsx
65
+ <MayaEditor mode="lite" initialContent={comment.body} onChange={setBody} />
66
+ ```
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@ceedcv-maya/shared-editor-react",
3
+ "version": "0.6.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "peerDependencies": {
8
+ "@tanstack/react-query": "^5.0.0",
9
+ "@tiptap/core": "^3.0.0",
10
+ "@tiptap/extension-color": "^3.0.0",
11
+ "@tiptap/extension-highlight": "^3.0.0",
12
+ "@tiptap/extension-image": "^3.0.0",
13
+ "@tiptap/extension-link": "^3.0.0",
14
+ "@tiptap/extension-table": "^3.0.0",
15
+ "@tiptap/extension-table-cell": "^3.0.0",
16
+ "@tiptap/extension-table-header": "^3.0.0",
17
+ "@tiptap/extension-table-row": "^3.0.0",
18
+ "@tiptap/extension-task-item": "^3.0.0",
19
+ "@tiptap/extension-task-list": "^3.0.0",
20
+ "@tiptap/extension-text-align": "^3.0.0",
21
+ "@tiptap/extension-text-style": "^3.0.0",
22
+ "@tiptap/extension-underline": "^3.0.0",
23
+ "@tiptap/pm": "^3.0.0",
24
+ "@tiptap/react": "^3.0.0",
25
+ "@tiptap/starter-kit": "^3.0.0",
26
+ "dompurify": "^3.0.0",
27
+ "mammoth": "^1.8.0",
28
+ "react": "^18.0.0 || ^19.0.0",
29
+ "react-dom": "^18.0.0 || ^19.0.0"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "mammoth": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@types/dompurify": "^3.0.0",
38
+ "@types/react": "^19.0.0",
39
+ "@types/react-dom": "^19.0.0",
40
+ "jsdom": "^29.1.1",
41
+ "react": "^19.0.0",
42
+ "react-dom": "^19.0.0",
43
+ "vitest": "^4.1.8"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc --noEmit",
47
+ "test": "vitest run",
48
+ "typecheck": "tsc --noEmit",
49
+ "lint": "echo \"no linter configured\""
50
+ },
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/Maya-AQSS/maya_platform.git",
55
+ "directory": "packages/js/shared-editor-react"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "description": "Unified rich-text editor for Maya: MayaEditor (TipTap) with lite/full modes, BlockNote→TipTap converter, HTML SSR renderer, anchored-comments support.",
61
+ "keywords": [
62
+ "react",
63
+ "tiptap",
64
+ "prosemirror",
65
+ "blocknote",
66
+ "editor",
67
+ "wysiwyg",
68
+ "ceedcv",
69
+ "maya"
70
+ ],
71
+ "author": {
72
+ "name": "CEEDCV",
73
+ "email": "info@ceedcv.es",
74
+ "homepage": "https://ceedcv.es"
75
+ },
76
+ "homepage": "https://github.com/Maya-AQSS/shared-editor-react#readme",
77
+ "bugs": {
78
+ "url": "https://github.com/Maya-AQSS/maya_platform/issues"
79
+ },
80
+ "sideEffects": false,
81
+ "files": [
82
+ "src",
83
+ "LICENSE",
84
+ "README.md",
85
+ "tsconfig.json"
86
+ ]
87
+ }
@@ -0,0 +1,100 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ interface ColorPickerProps {
4
+ title: string;
5
+ /** Currently applied colour (used for the swatch indicator + state). */
6
+ value?: string | null;
7
+ /** Called with a 6-digit hex or `null` (clear). */
8
+ onSelect: (color: string | null) => void;
9
+ /** Glyph shown inside the trigger button (e.g. `A` for text, `▮` for bg). */
10
+ glyph: React.ReactNode;
11
+ clearLabel?: string;
12
+ }
13
+
14
+ const PALETTE: Array<{ value: string | null; label: string }> = [
15
+ { value: null, label: 'Default' },
16
+ { value: '#000000', label: 'Black' },
17
+ { value: '#5b5b5b', label: 'Grey' },
18
+ { value: '#ef4444', label: 'Red' },
19
+ { value: '#f97316', label: 'Orange' },
20
+ { value: '#f59e0b', label: 'Yellow' },
21
+ { value: '#10b981', label: 'Green' },
22
+ { value: '#06b6d4', label: 'Cyan' },
23
+ { value: '#3b82f6', label: 'Blue' },
24
+ { value: '#8b5cf6', label: 'Purple' },
25
+ { value: '#ec4899', label: 'Pink' },
26
+ { value: '#a16207', label: 'Brown' },
27
+ ];
28
+
29
+ /**
30
+ * Lightweight palette popover used by the editor toolbar for text colour
31
+ * and highlight (background) selection. Closes on outside click, Escape,
32
+ * or after a swatch is chosen.
33
+ */
34
+ export function ColorPicker({ title, value, onSelect, glyph, clearLabel = 'Default' }: ColorPickerProps) {
35
+ const [open, setOpen] = useState(false);
36
+ const wrapperRef = useRef<HTMLDivElement | null>(null);
37
+
38
+ useEffect(() => {
39
+ if (!open) return;
40
+ const onDocClick = (e: MouseEvent) => {
41
+ if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
42
+ };
43
+ const onKey = (e: KeyboardEvent) => {
44
+ if (e.key === 'Escape') setOpen(false);
45
+ };
46
+ document.addEventListener('mousedown', onDocClick);
47
+ document.addEventListener('keydown', onKey);
48
+ return () => {
49
+ document.removeEventListener('mousedown', onDocClick);
50
+ document.removeEventListener('keydown', onKey);
51
+ };
52
+ }, [open]);
53
+
54
+ const isActive = !!value;
55
+
56
+ return (
57
+ <div className="maya-editor-color" ref={wrapperRef}>
58
+ <button
59
+ type="button"
60
+ title={title}
61
+ aria-label={title}
62
+ aria-pressed={isActive ? 'true' : undefined}
63
+ aria-haspopup="true"
64
+ aria-expanded={open}
65
+ onClick={() => setOpen((v) => !v)}
66
+ className={`maya-editor-toolbar__btn maya-editor-color__btn${isActive ? ' is-active' : ''}`}
67
+ >
68
+ <span className="maya-editor-color__glyph">{glyph}</span>
69
+ <span
70
+ className="maya-editor-color__swatch"
71
+ style={value ? { background: value } : undefined}
72
+ aria-hidden
73
+ />
74
+ </button>
75
+ {open && (
76
+ <div className="maya-editor-color__panel" role="menu">
77
+ {PALETTE.map((item) => (
78
+ <button
79
+ key={item.value ?? '__default__'}
80
+ type="button"
81
+ role="menuitem"
82
+ className={`maya-editor-color__cell${value === item.value ? ' is-active' : ''}`}
83
+ title={item.value === null ? clearLabel : item.label}
84
+ aria-label={item.value === null ? clearLabel : item.label}
85
+ onClick={() => {
86
+ onSelect(item.value);
87
+ setOpen(false);
88
+ }}
89
+ style={
90
+ item.value === null
91
+ ? { backgroundImage: 'linear-gradient(45deg, transparent 47%, #d33 47%, #d33 53%, transparent 53%)' }
92
+ : { background: item.value }
93
+ }
94
+ />
95
+ ))}
96
+ </div>
97
+ )}
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ export interface CommentHoverData {
5
+ /** Display name of the comment author. */
6
+ author?: string;
7
+ /** ISO/locale string shown under the author; consumer formats it. */
8
+ createdAt?: string;
9
+ /** Body of the comment (plain text, line breaks are honoured). */
10
+ body: string;
11
+ }
12
+
13
+ interface CommentHoverPopoverProps {
14
+ comment: CommentHoverData | null;
15
+ /** Viewport-relative coordinates of the highlighted span. */
16
+ anchorRect: DOMRect | null;
17
+ isDark?: boolean;
18
+ /** Optional close button label (used only for the aria-label). */
19
+ closeLabel?: string;
20
+ }
21
+
22
+ /**
23
+ * Tooltip-style popover anchored above an editor span carrying a
24
+ * `data-comment-id`. Renders via a portal so it escapes the editor's
25
+ * `overflow: hidden` clip box and isn't clipped by the wizard layout.
26
+ *
27
+ * Positioning: prefers the top edge of the highlighted span; flips below
28
+ * when there's no room above (within 16px viewport padding).
29
+ */
30
+ export function CommentHoverPopover({
31
+ comment,
32
+ anchorRect,
33
+ isDark = false,
34
+ closeLabel = 'Close',
35
+ }: CommentHoverPopoverProps) {
36
+ const ref = useRef<HTMLDivElement | null>(null);
37
+ const [size, setSize] = useState<{ width: number; height: number } | null>(null);
38
+
39
+ useEffect(() => {
40
+ if (!ref.current || !comment) return;
41
+ const r = ref.current.getBoundingClientRect();
42
+ setSize({ width: r.width, height: r.height });
43
+ }, [comment?.body, comment?.author]);
44
+
45
+ if (!comment || !anchorRect || typeof document === 'undefined') return null;
46
+
47
+ const padding = 8;
48
+ const margin = 16;
49
+ const popoverW = size?.width ?? 280;
50
+ const popoverH = size?.height ?? 80;
51
+ const vw = window.innerWidth;
52
+ const vh = window.innerHeight;
53
+
54
+ // Place above if there's room, otherwise below.
55
+ const placeAbove = anchorRect.top - popoverH - padding > margin;
56
+ const top = placeAbove
57
+ ? Math.max(margin, anchorRect.top - popoverH - padding)
58
+ : Math.min(vh - popoverH - margin, anchorRect.bottom + padding);
59
+
60
+ // Centre horizontally over the span; clamp to viewport.
61
+ const centerX = anchorRect.left + anchorRect.width / 2;
62
+ const left = Math.max(margin, Math.min(vw - popoverW - margin, centerX - popoverW / 2));
63
+
64
+ return createPortal(
65
+ <div
66
+ ref={ref}
67
+ className={`maya-comment-popover${isDark ? ' is-dark' : ''}`}
68
+ role="tooltip"
69
+ aria-label={closeLabel}
70
+ style={{ top, left, maxWidth: 320 }}
71
+ >
72
+ <div className="maya-comment-popover__header">
73
+ <span className="maya-comment-popover__author">{comment.author ?? 'Comentario'}</span>
74
+ {comment.createdAt && (
75
+ <span className="maya-comment-popover__date">{comment.createdAt}</span>
76
+ )}
77
+ </div>
78
+ <div className="maya-comment-popover__body">{comment.body}</div>
79
+ </div>,
80
+ document.body,
81
+ );
82
+ }
@@ -0,0 +1,29 @@
1
+ import { useMemo } from 'react';
2
+ import { sanitizeEditorHtml } from '../lib/dompurifyConfig';
3
+
4
+ interface EditorContentHtmlProps {
5
+ /** Pre-rendered HTML (typically from server-side TiptapHtmlRenderer). */
6
+ html: string;
7
+ className?: string;
8
+ }
9
+
10
+ /**
11
+ * Renders pre-sanitised editor HTML. Use this for read-only views (PDF
12
+ * previews, validator views, search hit cards) — for editing, use
13
+ * `<MayaEditor />`.
14
+ *
15
+ * Sanitises with the package DOMPurify config (aligned with the SSR
16
+ * renderer) — defence in depth, not a substitute for server-side
17
+ * sanitisation.
18
+ */
19
+ export function EditorContentHtml({ html, className }: EditorContentHtmlProps) {
20
+ const safeHtml = useMemo(() => sanitizeEditorHtml(html), [html]);
21
+ if (!safeHtml) return null;
22
+
23
+ return (
24
+ <div
25
+ className={className ?? 'maya-editor-content'}
26
+ dangerouslySetInnerHTML={{ __html: safeHtml }}
27
+ />
28
+ );
29
+ }
@@ -0,0 +1,225 @@
1
+ import type { Editor } from '@tiptap/react';
2
+ import type { EditorMode } from '../types';
3
+ import { Btn } from './EditorToolbarButton';
4
+ import {
5
+ FormattingButtons,
6
+ AdvancedFormattingButtons,
7
+ AlignmentButtons,
8
+ IndentButtons,
9
+ HeadingButtons,
10
+ ListAndBlockButtons,
11
+ TableAndMediaButtons,
12
+ DocumentButtons,
13
+ ViewModeButtons,
14
+ } from './EditorToolbarGroups';
15
+
16
+ interface EditorToolbarProps {
17
+ editor: Editor | null;
18
+ mode: EditorMode;
19
+ isFullscreen?: boolean;
20
+ onToggleFullscreen?: () => void;
21
+ onInsertHtml?: () => void;
22
+ onInsertMarkdown?: () => void;
23
+ viewMode?: 'wysiwyg' | 'html' | 'markdown';
24
+ onImage?: () => void;
25
+ onImportDocx?: () => void;
26
+ onExportDocx?: () => void;
27
+ onAddComment?: () => void;
28
+ onToggleFind?: () => void;
29
+ labels?: ToolbarLabels;
30
+ }
31
+
32
+ export interface ToolbarLabels {
33
+ bold: string;
34
+ italic: string;
35
+ underline: string;
36
+ strike: string;
37
+ code: string;
38
+ link: string;
39
+ linkPrompt: string;
40
+ unlink: string;
41
+ heading1: string;
42
+ heading2: string;
43
+ heading3: string;
44
+ bulletList: string;
45
+ orderedList: string;
46
+ taskList: string;
47
+ blockquote: string;
48
+ codeBlock: string;
49
+ horizontalRule: string;
50
+ image: string;
51
+ table: string;
52
+ alert: string;
53
+ iframe: string;
54
+ iframePrompt: string;
55
+ fullscreen: string;
56
+ exitFullscreen: string;
57
+ insertHtml: string;
58
+ insertMarkdown: string;
59
+ alignLeft: string;
60
+ alignCenter: string;
61
+ alignRight: string;
62
+ alignJustify: string;
63
+ indent: string;
64
+ outdent: string;
65
+ textColor: string;
66
+ backgroundColor: string;
67
+ colorDefault: string;
68
+ undo: string;
69
+ redo: string;
70
+ uploadImage: string;
71
+ importDocx: string;
72
+ exportDocx: string;
73
+ addComment: string;
74
+ find: string;
75
+ findPlaceholder?: string;
76
+ replacePlaceholder?: string;
77
+ findNext?: string;
78
+ findPrev?: string;
79
+ replace?: string;
80
+ replaceAll?: string;
81
+ findClose?: string;
82
+ caseSensitive?: string;
83
+ findNone?: string;
84
+ }
85
+
86
+ const DEFAULT_LABELS: ToolbarLabels = {
87
+ bold: 'Bold',
88
+ italic: 'Italic',
89
+ underline: 'Underline',
90
+ strike: 'Strike',
91
+ code: 'Code',
92
+ link: 'Link',
93
+ linkPrompt: 'URL',
94
+ unlink: 'Unlink',
95
+ heading1: 'H1',
96
+ heading2: 'H2',
97
+ heading3: 'H3',
98
+ bulletList: 'Bullet list',
99
+ orderedList: 'Numbered list',
100
+ taskList: 'Task list',
101
+ blockquote: 'Quote',
102
+ codeBlock: 'Code block',
103
+ horizontalRule: 'HR',
104
+ image: 'Image',
105
+ table: 'Table',
106
+ alert: 'Alert',
107
+ iframe: 'Iframe',
108
+ iframePrompt: 'Iframe URL',
109
+ fullscreen: 'Fullscreen',
110
+ exitFullscreen: 'Exit fullscreen',
111
+ insertHtml: 'Insert HTML',
112
+ insertMarkdown: 'Insert Markdown',
113
+ alignLeft: 'Align left',
114
+ alignCenter: 'Align center',
115
+ alignRight: 'Align right',
116
+ alignJustify: 'Justify',
117
+ indent: 'Increase indent',
118
+ outdent: 'Decrease indent',
119
+ textColor: 'Text color',
120
+ backgroundColor: 'Highlight color',
121
+ colorDefault: 'Default color',
122
+ undo: 'Undo',
123
+ redo: 'Redo',
124
+ uploadImage: 'Insert image',
125
+ importDocx: 'Import Word (.docx)',
126
+ exportDocx: 'Export Word (.docx)',
127
+ addComment: 'Comment selection',
128
+ find: 'Find and replace',
129
+ findPlaceholder: 'Find',
130
+ replacePlaceholder: 'Replace with',
131
+ findNext: 'Next match',
132
+ findPrev: 'Previous match',
133
+ replace: 'Replace',
134
+ replaceAll: 'Replace all',
135
+ findClose: 'Close find',
136
+ caseSensitive: 'Match case',
137
+ findNone: 'No matches',
138
+ };
139
+
140
+ /**
141
+ * EditorToolbar is a wrapper component that composes focused button groups.
142
+ * Each group handles a logically distinct set of toolbar buttons, keeping
143
+ * individual files under the 400-line target per coding-style.md.
144
+ */
145
+ export function EditorToolbar({
146
+ editor,
147
+ mode,
148
+ isFullscreen,
149
+ onToggleFullscreen,
150
+ onInsertHtml,
151
+ onInsertMarkdown,
152
+ viewMode = 'wysiwyg',
153
+ onImage,
154
+ onImportDocx,
155
+ onExportDocx,
156
+ onAddComment,
157
+ onToggleFind,
158
+ labels,
159
+ }: EditorToolbarProps) {
160
+ if (!editor) return null;
161
+ const L = labels ?? DEFAULT_LABELS;
162
+
163
+ const isLite = mode === 'lite';
164
+
165
+ return (
166
+ <div className="maya-editor-toolbar" role="toolbar" aria-label="Editor toolbar">
167
+ <Btn
168
+ disabled={!editor.can().undo()}
169
+ onClick={() => editor.chain().focus().undo().run()}
170
+ title={L.undo}
171
+ >
172
+
173
+ </Btn>
174
+ <Btn
175
+ disabled={!editor.can().redo()}
176
+ onClick={() => editor.chain().focus().redo().run()}
177
+ title={L.redo}
178
+ >
179
+
180
+ </Btn>
181
+
182
+ <span className="maya-editor-toolbar__sep" aria-hidden />
183
+ <FormattingButtons editor={editor} labels={L} />
184
+
185
+ {!isLite && (
186
+ <>
187
+ <span className="maya-editor-toolbar__sep" aria-hidden />
188
+ <AdvancedFormattingButtons editor={editor} labels={L} />
189
+
190
+ <span className="maya-editor-toolbar__sep" aria-hidden />
191
+ <AlignmentButtons editor={editor} labels={L} />
192
+
193
+ <span className="maya-editor-toolbar__sep" aria-hidden />
194
+ <IndentButtons editor={editor} labels={L} />
195
+
196
+ <span className="maya-editor-toolbar__sep" aria-hidden />
197
+ <HeadingButtons editor={editor} labels={L} />
198
+ <ListAndBlockButtons editor={editor} labels={L} />
199
+ <TableAndMediaButtons editor={editor} labels={L} onImage={onImage} />
200
+
201
+ <span className="maya-editor-toolbar__sep" aria-hidden />
202
+ <DocumentButtons
203
+ editor={editor}
204
+ labels={L}
205
+ onImportDocx={onImportDocx}
206
+ onExportDocx={onExportDocx}
207
+ onAddComment={onAddComment}
208
+ onToggleFind={onToggleFind}
209
+ />
210
+
211
+ <span className="maya-editor-toolbar__sep" aria-hidden />
212
+ <ViewModeButtons
213
+ editor={editor}
214
+ labels={L}
215
+ viewMode={viewMode}
216
+ isFullscreen={isFullscreen}
217
+ onInsertHtml={onInsertHtml}
218
+ onInsertMarkdown={onInsertMarkdown}
219
+ onToggleFullscreen={onToggleFullscreen}
220
+ />
221
+ </>
222
+ )}
223
+ </div>
224
+ );
225
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Toolbar button component used across EditorToolbar and its button groups.
3
+ */
4
+ interface BtnProps {
5
+ active?: boolean;
6
+ disabled?: boolean;
7
+ onClick: () => void;
8
+ title: string;
9
+ children: React.ReactNode;
10
+ }
11
+
12
+ export function Btn({
13
+ active,
14
+ disabled,
15
+ onClick,
16
+ title,
17
+ children,
18
+ }: BtnProps) {
19
+ return (
20
+ <button
21
+ type="button"
22
+ title={title}
23
+ aria-label={title}
24
+ aria-pressed={active ? 'true' : undefined}
25
+ disabled={disabled}
26
+ onClick={onClick}
27
+ className={`maya-editor-toolbar__btn${active ? ' is-active' : ''}`}
28
+ >
29
+ {children}
30
+ </button>
31
+ );
32
+ }